项目简介
在当今的大模型应用落地中,RAG(检索增强生成,Retrieval-Augmented Generation)已成为解决模型幻觉、打通企业私域数据的核心技术。简单来说,RAG 就像是给大模型配备了一个可以实时查阅的 “外部知识字典”——当用户提出问题时,系统会首先从海量的私域文档中检索出最相关的知识切片,然后将这些黄金上下文与问题一同喂给大模型,从而确保模型的回答基于客观事实,而非凭空捏造。
这里我选择 Qdrant 作为本项目的向量数据库底座。Qdrant 具有极其出色的 gRPC 读写性能、毫秒级的过滤检索响应,以及对动态元数据(Payload)的强大过滤能力,能够完美支撑起高频、低延迟的知识检索诉求,确保知识库的扩展性与检索精准度。
需要向读者说明的是,本项目具有极强的技术探索性质。我的核心目的不仅是为了搭建一个应用,更是为了通过手写代码,由浅入深地拆解 RAG 技术在大模型应用中的落地细节与底层运作机制。
技术参考:Embedding (Vector) Stores、Embedding Store、RAG、Langchan4j support for Qdrant、Qdrant。
本地 ollama 向量 LLM 的安装
别问我为什么不在阿里云 dashscope 进行测试,问就是没钱 😭。其实本地测试也挺好的,唯一的缺点就是慢…。好了废话不多说,首先是 Ollama 的安装,它是运行各种 LLM 的容器。参考资料 Ollama 下载地址和参考文档、Github Ollama。其次是安装大语言模型和向量模型。由于我的本地内存也不太富裕,语言模型选择 qwen3:4b。关于向量模型,阿里通义千问的最新配套向量模型名称叫 qwen:embedding(或者你可以首选本地最流行、生态兼容性极佳的 nomic-embed-text )。拉取完,它就会常驻在本地为你提供高效率的向量化服务。
1 2 3 4 5 6 7 8 9
| $ sudo ln -s /Applications/Ollama.app/Contents/Resources/ollama /usr/local/bin/ollama
$ ollama --version
$ ollama pull qwen3:4b $ ollama pull qwen3-embedding
|
本地 Qdrant 环境的准备
向量存储(VectorStore)是一种用于存储和检索高维向量数据的数据库或存储解决方案;它特别适用于处理那些经过嵌入模型转化后的数据。在 VectorStore 中,查询与传统关系数据库不同。它们执行相似性搜索,而不是精确匹配。当给定一个向量作为查询时,VectorStore 返回与查询向量相似的向量。本文我们使用 Qdrant 作为向量数据库。使用 Docker Desktop 安装和配置 Qdrant 非常简单。因为 Qdrant 是用 Rust 编写的,整个镜像极为轻量,并且它原生自带了一个 Dashboard。
1 2 3 4 5 6 7 8 9 10 11
|
$ docker run -d \ --name qdrant-local \ -p 6333:6333 \ -p 6334:6334 \ -v $(pwd)/qdrant_storage:/tmp/qdrant/storage \ qdrant/qdrant:latest
$ curl http://localhost:6333/dashboard
|
在 Qdrant 中,数据的核心单位叫做 Collection(集合),等同于关系型数据库中的表。我们可以直接在浏览器自带的 Console 中,或者在你本地的终端用 curl 命令,创建一个集合备用:
1 2 3 4 5 6 7 8
| $ curl -X PUT "http://localhost:6333/collections/owlias_knowledge_base" \ -H "Content-Type: application/json" \ -d '{ "vectors": { "size": 4096, "distance": "Cosine" } }'
|
项目依赖和基础演示
项目依赖
下面是该项目中需要用到的一些依赖包,全部都是按最新版本来整。其父项目可以参考 父项目-POM。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-reactor</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-qdrant</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-tika</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-http-client-jdk</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> </dependency> </dependencies>
|
基本演示代码
过程演示代码
基于以上环境的准备,我们就可以先来构建一个简单的基于本地内存向量的 RAG 演示程序,目的是跑通流程。以此为起点,我们将逐步过度到使用 Qdrant 向量数据库将私域知识持久化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
| import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.http.client.jdk.JdkHttpClient; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiChatModelName; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.model.openai.OpenAiTokenCountEstimator; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.service.AiServices; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import reactor.core.publisher.Flux; import java.net.http.HttpClient; import java.nio.file.Paths; import java.time.Duration; import java.util.List; import java.util.concurrent.CountDownLatch;
public class InMemoryRagExample {
interface KnowledgeAssistant { Flux<String> answer(String question); }
public static void main(String[] args) throws InterruptedException { StreamingChatModel chatModel = OpenAiStreamingChatModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("gemma3:1b") .temperature(0.0) .timeout(Duration.ofSeconds(1200)) .logRequests(true) .logResponses(false) .build();
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3-embedding") .timeout(Duration.ofSeconds(600)) .maxRetries(3) .logRequests(true) .logResponses(true) .httpClientBuilder(JdkHttpClient.builder() .httpClientBuilder( HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60)) ) .readTimeout(Duration.ofSeconds(300))) .build();
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
var documentSplitter = DocumentSplitters.recursive( 300, 30, new OpenAiTokenCountEstimator(OpenAiChatModelName.GPT_5) );
Document privateDocument = Document.from(""" 项目 Owlias v1.3 的全新架构由张三于2026年独立开发完成,核心通讯底座基于高性能 Netty 构建。 """ );
Document pdfDocument = FileSystemDocumentLoader.loadDocument( Paths.get("/xxx/产品书册01.pdf"), new ApacheTikaDocumentParser() );
Document wordDocument = FileSystemDocumentLoader.loadDocument( Paths.get("/xxx/产品手册02.docx"), new ApacheTikaDocumentParser() );
System.out.println("--- 开始流式单条文本块灌入(化整为零) ---"); List<Document> allDocs = List.of(privateDocument, pdfDocument, wordDocument); int totalSegments = 0;
for (int i = 0; i < allDocs.size(); i++) { Document doc = allDocs.get(i); List<TextSegment> segments = documentSplitter.split(doc); System.out.printf("[文档 %d] 切片完成,共解析出 %d 个文本块。开始逐个向 Ollama 申请向量...\n", i + 1, segments.size());
for (int j = 0; j < segments.size(); j++) { TextSegment segment = segments.get(j);
var embeddingContent = embeddingModel.embed(segment).content(); embeddingStore.add(embeddingContent, segment);
totalSegments++; if (totalSegments % 10 == 0 || j == segments.size() - 1) { System.out.printf(" └进度通知:已成功安全向量化并存入 %d 个切片...\n", totalSegments); } } } System.out.println("--- 所有文档切片全部真·单发同步成功!向量库已就绪 ---");
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(1) .minScore(0.6) .build();
KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class) .streamingChatModel(chatModel) .contentRetriever(contentRetriever) .build();
String question = "owlias 响应码 00001 代表什么?使用中文严格回答,不要瞎编。"; System.out.println("用户提问: " + question);
Flux<String> response = assistant.answer(question); CountDownLatch countDownLatch = new CountDownLatch(1); response.subscribe( token -> { System.out.print(token); System.out.flush(); }, error -> { System.err.println("\n发生异常: " + error.getMessage()); countDownLatch.countDown(); }, () -> { System.out.println("\n\n--- 文本流传输结束 ---"); countDownLatch.countDown(); } ); countDownLatch.await(); } }
|
这只是一个最简单的演示程序,它最大的缺点就是将文档的向量化和知识的检索耦合在了一起,非常低效!
常见文档加载器
常见的文档加载器 Document Loader:
- 来自 langchain4j 模块的文件系统文档加载器(FileSystemDocumentLoader)
- 来自 langchain4j 模块的类路径文档加载器(ClassPathDocumentLoader)
- 来自 langchain4j 模块的网址文档加载器(UrlDocumentLoader)
- 来自 langchain4j-document-loader-amazon-s3 模块的亚马逊 S3 文档加载器(AmazonS3DocumentLoader)
- 来自 langchain4j-document-loader-azure-storage-blob 存储文档加载器(AzureBlobStorageDocumentLoader)
- 来自 langchain4j-document-loader-github 模块的GitHub 文档加载器(GitHubDocumentLoader)
- 来自 langchain4j-document-loader-google-cloud-storage 模块的加载器(GoogleCloudStorageDocumentLoader)
- 来自 langchain4j-document-loader-selenium 模块的Selenium 文档加载器(SeleniumDocumentLoader)
- 来自 langchain4j-document-loader-tencent-cos 模块的腾讯云对象存储文档加载器(TencentCosDocumentLoader)
常见文档解析器
常见文档解析器 Document Parser:
文档可以是各种格式的文件,比如 PDF、DOC、TXT 等等。为了解析这些不同格式的文件,有一个 “文档解析器”接口 DocumentParser,并且我们的库中包含了该接口的几种实现方式:
- 来自 langchain4j 模块的文本文档 TextDocumentParser,它能够解析纯文本格式的文件(TXT、HTML、MD 等)。
- 来自 langchain4j-document-parser-apache-pdfbox 模块的 ApachePdfBoxDocumentParser,它可以解析 PDF 文件。
- 来自 langchain4j-document-parser-apache-poi 模块的 Apache POI 文档解析器(ApachePoiDocumentParser),它能够解析微软办公软件的文件格式(例如 DOC、DOCX、PPT、PPTX、XLS、XLSX 等)。
- 来自 langchain4j-document-parser-apache-tika模块的 Apache Tika 文档解析器
ApacheTikaDocumentParser,它可以自动检测并解析几乎所有现有的文件格式。
常见文档分割器
常见文档分割器 Splitter:
- 按段落文档分割器(DocumentByParagraphSplitter)
- 按行文档分割器(DocumentByLineSplitter)
- 按句子文档分割器(DocumentBySentenceSplitter)
- 按单词文档分割器(DocumentByWordSplitter)
- 按字符文档分割器(DocumentByCharacterSplitter)
- 按正则表达式文档分割器(DocumentByRegexSplitter)
- 递归分割:DocumentSplitters.recursive(…)
默认情况下每个文本片段最多不能超过 300 个 token,这是 LangChain4j 内置的 “安全上限”,意思是:任何一个 TextSegment 在送入 Embedding 模型前,token 数 ≤ 300。它的目的是防止超过 Embedding 模型的最大输入长度(如 text-embedding-v2/ qwen3-embedding)。
DocumentSplitter 的工作方式是两级拆分:
1 2 3 4 5
| Document └── 一级拆分(粗粒度) └── TextSegment(可能还太大) └── 二级拆分(子分割器,自动触发) └── 最终 TextSegment(≤300 token)
|
具体的工作过程:
DocumentSplitter 只做一件事,把 Document→ List<TextSegment>,它本身不关心语义,只关心:按什么规则切(段落 / 行 / 句子),每个 segment 多大,是否允许再切(子分割)。
300 token 限制的 “触发点”(关键类 DefaultDocumentSplitter),超过 300 个 token 就一定会再切,子分割器是自动创建的。子分割器如下【一级 Splitter -> 默认子 Splitter】:
- DocumentByParagraphSplitter ->DocumentBySentenceSplitter
- DocumentByLineSplitter -> DocumentBySentenceSplitter
- DocumentBySentenceSplitter -> DocumentByWordSplitter
- DocumentByWordSplitter -> DocumentByCharacterSplitter
- DocumentByCharacterSplitter -> 不再切
一级切不下来就自动降级到更细粒度,这种递归的实现是内部完成的,你不需要手写递归。我们需要做的就是选对一级 Splitter(最重要)
1 2 3 4
| DocumentSplitter splitter = new DocumentByParagraphSplitter( 300, 50 );
|
一般不需要手动指定子 Splitter,除非你有特殊需求,如:法律合同须按“条款”切,代码按函数或类切,表格按行切:
1 2 3 4 5 6
| DocumentSplitter splitter = new DocumentByParagraphSplitter( 300, 50, new FixedTokenizer(), new DocumentBySentenceSplitter(...) );
|
向量化过程和检索过程解耦
向量化过程

向量化过程的实现:使用 Qdrant 实现私域知识的持久化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.http.client.jdk.JdkHttpClient; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiChatModelName; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.openai.OpenAiTokenCountEstimator; import dev.langchain4j.store.embedding.qdrant.QdrantEmbeddingStore; import java.net.http.HttpClient; import java.nio.file.Paths; import java.time.Duration; import java.util.ArrayList; import java.util.List;
public class IngestionPipeline { private static final String qdrantHost = "localhost"; private static final int qdrantGrpcPort = 6334; private static final String collectionName = "owlias_knowledge_base";
public static void main(String[] args) { EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3-embedding") .timeout(Duration.ofSeconds(1200)) .maxRetries(3) .logRequests(true) .logResponses(true) .httpClientBuilder(JdkHttpClient.builder() .httpClientBuilder(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60))) .readTimeout(Duration.ofSeconds(300))) .build();
QdrantEmbeddingStore embeddingStore = QdrantEmbeddingStore.builder() .host(qdrantHost) .port(qdrantGrpcPort) .collectionName(collectionName) .build();
var documentSplitter = DocumentSplitters.recursive( 300, 30, new OpenAiTokenCountEstimator(OpenAiChatModelName.GPT_5) );
System.out.println("[数据清洗] 正在加载物理文档..."); Document privateDoc = Document.from( """ 2025年全国高考报名人数为 1335万人,比2024年的1342万人减少7万人,是多年来报名人数首次下降。 根据国家统计局公布的数据,2025年全国普通与职业本专科计划招生总人数为 1070.8万人。其中,本科录取人数约为480万人。 """ ); Document pdfDoc = FileSystemDocumentLoader.loadDocument(Paths.get("/xxx/广东省2025年本科普通类(历史)投档情况.pdf"), new ApacheTikaDocumentParser()); Document wordDoc = FileSystemDocumentLoader.loadDocument(Paths.get("/xxx/山东省普通高校招生志愿填报百问百答.doc"), new ApacheTikaDocumentParser()); List<Document> documents = List.of(privateDoc, pdfDoc, wordDoc);
List<TextSegment> allSegments = new ArrayList<>(); for (Document doc : documents) { allSegments.addAll(documentSplitter.split(doc)); } int totalSegmentSize = allSegments.size(); System.out.printf("[数据清洗] 文档解析完毕,全量切片已生成,总体积:%d 个文本块。\n", totalSegmentSize);
int batchSize = 2; System.out.println("[向量化] 启动平滑灌入队列,策略:每 "+ batchSize +" 条切片打包一次..."); for (int i = 0; i < totalSegmentSize; i += batchSize) { List<TextSegment> batchSegments = allSegments.subList(i, Math.min(i + batchSize, totalSegmentSize));
var embeddings = embeddingModel.embedAll(batchSegments).content();
embeddingStore.addAll(embeddings, batchSegments); System.out.printf(" └ 已成功将切片区间 [%d ~ %d]/%d 同步持久化至 Qdrant 数据库。\n", i, i + batchSegments.size() - 1, totalSegmentSize); } System.out.println("\n--- [SUCCESS] 离线知识库向量化成功! ---"); } }
|
控制台日志输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| [数据清洗] 正在加载物理文档... [数据清洗] 文档解析完毕,全量切片已生成,总体积:215 个文本块。 [向量化] 启动平滑灌入队列,策略:每 2 条切片打包一次... 00:00:26.725 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request: - method: POST - url: http: - headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json] - body: { "model" : "qwen3-embedding", "input" : [ "2025年全国高考报名人数为 1335万人,比2024年的1342万人减少7万人,是多年来报名人数首次下降。\n根据国家统计局公布的数据,2025年全国普通与职业本专科计划招生总人数为 1070.8万人。其中,本科录取人数约为480万人。", "广东省2025年本科普通类(历史)投档情况\n院校代码 院校名称 专业组代码 计划数 投档人数 投档最低分 投档最低排位\n\n10001 北京大学 205 24 24 669 28\n\n10002 中国人民大学 205 56 56 655 148\n\n10002 中国人民大学 208 ..." ] }
00:01:45.630 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP response: - status code: 200 - headers: [content-type: application/json], [date: Sat, 11 Jul 2026 16:01:45 GMT], [transfer-encoding: chunked] - body: {"object":"list","data":[{"object":"embedding","embedding":[0.02671325,0.013727239,-0.018859694,...],"index":1}],"model":"qwen3-embedding","usage":{"prompt_tokens":510,"total_tokens":510}}
└ 已成功将切片区间 [0 ~ 1]/215 同步持久化至 Qdrant 数据库。 00:01:45.961 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request: - method: POST - url: http: - headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json] - body: { "model" : "qwen3-embedding", "input" : [ "10019 中国农业大学 209 7 7 605 4208\n\n10022 北京林业大学 220 12 12 595 6372\n\n10022 北京林业大学 221 4 4 591 7300\n\n10022 北京林业大学 224 2 2 596 6004\n\n10022 北京...北京体育大学(中外合作办学) 217 8 8 542 28624" ] } ... --- [SUCCESS] 离线知识库向量化成功! ---
|
查看本地 Qdrant 向量数据库,一切OK!


实时问答与检索

向量化和问答走两条线,互不干扰:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
|
public class LiveRAGServer { interface KnowledgeAssistant { Flux<String> answer(String question); }
public static void main(String[] args) throws InterruptedException { StreamingChatModel chatModel = OpenAiStreamingChatModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3:4b") .temperature(0.0) .timeout(Duration.ofSeconds(1200)) .logRequests(true) .logResponses(false) .build();
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3-embedding") .timeout(Duration.ofSeconds(600)) .maxRetries(3) .logRequests(true) .logResponses(true) .build();
QdrantEmbeddingStore embeddingStore = QdrantEmbeddingStore.builder() .host("localhost") .port(6334) .collectionName("owlias_knowledge_base") .build();
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build();
KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class) .streamingChatModel(chatModel) .contentRetriever(contentRetriever) .build();
String question = "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。"; System.out.println("[系统就绪] 正在向 Qdrant 毫秒级检索上下文并激活大模型..."); System.out.println("用户输入: " + question); System.out.print("AI 响应: -> ");
long startTime = System.currentTimeMillis(); Flux<String> responseStream = assistant.answer(question); CountDownLatch latch = new CountDownLatch(1);
responseStream.subscribe( token -> { System.out.print(token); System.out.flush(); }, error -> { System.err.println("\n[ERROR] 运行时发生异常: " + error.getMessage()); latch.countDown(); }, () -> { long duration = System.currentTimeMillis() - startTime; System.out.printf("\n\n--- 文本流传输结束 (首屏耗时及检索总响应: %d ms) ---\n", duration); latch.countDown(); } );
latch.await(); } }
|
控制台日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| [系统就绪] 正在向 Qdrant 毫秒级检索上下文并激活大模型... 用户输入: 2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。 AI 响应: -> 00:29:02.745 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request: - method: POST - url: http://localhost:11434/v1/embeddings - headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json] - body: { "model" : "qwen3-embedding", "input" : [ "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。" ] }
00:29:08.135 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP response: - status code: 200 - headers: [content-type: application/json], [date: Sat, 11 Jul 2026 16:29:08 GMT], [transfer-encoding: chunked] - body: {"object":"list","data":[{"object":"embedding","embedding":[0.02146144,0.022769632,0.0018570911,-0.010346769,...-0.006307611],"index":0}],"model":"qwen3-embedding","usage":{"prompt_tokens":27,"total_tokens":27}}
00:29:08.808 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request: - method: POST - url: http://localhost:11434/v1/chat/completions - headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json] - body: { "model" : "qwen3:4b", "messages" : [ { "role" : "user", "content" : "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。\n\nAnswer using the following information:\n广东省2025年本科普通类(历史)投档情况\n院校代码 院校名称 专业组代码 计划数 投档人数 投档最低分 投档最低排位\n\n10001 北京大学 205 24 24 669 28\n\n10002 中国人民大学 205 56 56 655 148\n\n10002 中国人民大学 208 6 6 666 47\n\n10003 清华大学 203 4 4 675 13\n\n10004 北京交通大学 ...\n\n10107 石家庄铁道大学 206 2 2 536 32215" } ], "temperature" : 0.0, "stream" : true, "stream_options" : { "include_usage" : true } }
嗯,用户问的是2025年广东省本科普通类历史专业中清华大学的计划数和投档人数。这个问题需要仔细核对提供的数据。... </think>
根据提供的广东省2025年本科普通类(历史)投档数据表,清华大学的计划数和投档人数如下:
**院校代码**:10003 **院校名称**:清华大学 **专业组代码**:203 **计划数**:4 **投档人数**:4
**严格回答**: 清华大学2025年广东省本科普通类(历史)专业计划数为 **4**,投档人数为 **4**。
--- 文本流传输结束 (首屏耗时及检索总响应: 207411 ms) ---
|
OK,一切安好。从以上日志输出我们也可以得出 RAG 的完整分工图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| parser + splitter + qwen3-embedding(Embedding Model) + qdrant(VecStore) 向量数据库不会、也不可能完成文本向量化,它只认向量,不认文本。 只要系统里出现向量数据库,就必定伴随一个 Embedding 模型。区别只是你显式管理还是云厂商隐式托管。
用户问题 ↓ 向量数据库(检索 Top-K 相关片段) ↓ Prompt = 问题 + 检索结果 + 指令 ↓ LLM 大模型(理解 + 推理 + 生成)👈🏻 这才是核心,VecStore 是做不到这三点的! ↓ 最终答案
|
接口服务化
向量化和实时问答两条路都跑通了。下面就该向外暴露调用接口了。
启动类
1 2 3 4 5 6
| @SpringBootApplication public class App { public static void main(String[] args) { SpringApplication.run(App.class, args); } }
|
配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13
| server: port: 8080 servlet: encoding: charset: UTF-8 enabled: true force: true
logging: level: root: INFO dev.langchain4j: ERROR
|
AiService 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public interface KnowledgeAssistant { @SystemMessage(""" # Role 你是 Owlias 官方教育知识小助手。你的核心职责是为用户提供专业、准确、耐心的知识解答。 # Severe Restrictions (硬性防线) 1. 严格基于召回的上下文进行回答。如果上下文中没有提到、或者你无法从中推导出来,请直接回答:“抱歉,在 Owlias 现有知识库中未检索到相关信息,我无法为您解答。”,绝对不允许发挥想象力瞎编或瞎猜。 2. 当用户询问项目架构、响应码(例如 00001)时,必须严格比对上下文中的技术定义,确保术语的工程严谨性。 # Tone & Style - 保持温暖、亲切且专业的专业导师语气。 - 逻辑清晰,善于使用 Markdown 的列表(-)或粗体(**)来梳理结构,避免大段黏稠的文字。 - 涉及代码或技术架构时,请直接给出具体、可直接阅读的结构。 """) Flux<String> answer(@MemoryId Long userId, @UserMessage String question); }
|
组装配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.service.AiServices; import dev.langchain4j.store.embedding.qdrant.QdrantEmbeddingStore; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration;
@Configuration public class LLMConfig {
@Bean public StreamingChatModel localQwen3StreamingChatModel() { return OpenAiStreamingChatModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3:4b") .temperature(0.0) .timeout(Duration.ofSeconds(1200)) .logRequests(false) .logResponses(false) .build(); }
@Bean public OpenAiEmbeddingModel localQwenEmbeddingModel() { return OpenAiEmbeddingModel.builder() .baseUrl("http://localhost:11434/v1") .apiKey("api_key_xxx") .modelName("qwen3-embedding") .timeout(Duration.ofSeconds(600)) .maxRetries(3) .logRequests(false) .logResponses(false) .build(); }
@Bean public QdrantEmbeddingStore qdrantEmbeddingStore() { return QdrantEmbeddingStore.builder() .host("localhost") .port(6334) .collectionName("owlias_knowledge_base") .build(); }
@Bean public ContentRetriever contentRetriever( QdrantEmbeddingStore embeddingStore, OpenAiEmbeddingModel embeddingModel) { return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build(); }
@Bean public KnowledgeAssistant knowledgeAssistant( @Qualifier("localQwen3StreamingChatModel") StreamingChatModel streamingChatModel, ContentRetriever contentRetriever) { return AiServices.builder(KnowledgeAssistant.class) .streamingChatModel(streamingChatModel) .contentRetriever(contentRetriever) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id("chat:" + memoryId) .alwaysKeepSystemMessageFirst(true) .dynamicMaxMessages(userId -> 10) .build()) .build(); } }
|
业务测试类
1 2 3 4 5 6 7 8 9 10 11 12
| @RestController public class EduAssistantController {
@Resource private KnowledgeAssistant knowledgeAssistant;
@GetMapping(value = "/answer", produces = MediaType.TEXT_PLAIN_VALUE) public Flux<String> answer(@RequestParam(value = "userId") Long userId, @RequestParam(value = "question") String question) { return knowledgeAssistant.answer(userId, question); } }
|
访问接口,我们就可以看到浏览器那熟悉的打字机响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| $ curl -N -G 'http://localhost:8080/answer' \ --data-urlencode 'userId=1' \ --data-urlencode 'question=我是张三,想知道北京大学2025年历史专业的报考情况'
... ... 看起来OK。 </think>
你好,张三!
根据Owlias知识库中检索到的**广东省2025年本科普通类(历史)投档数据**(注:此数据仅适用于广东省考生,全国报考情况需结合各省份招生政策),北京大学的报考情况如下:
- **院校代码**:10001 - **院校名称**:北京大学 - **专业组代码**:205 - **计划数**:24人 - **投档人数**:24人(全部投档) - **投档最低分**:669分 - **投档最低排位**:28(广东省内)
> 📌 **重要提示**: > 1. 以上数据为**广东省2025年历史类投档情况**(普通类历史),实际报考时需以北京大学官方招生章程为准,不同省份、不同专业组的录取规则可能有差异。 > 2. “历史专业”在高考中通常指历史类考生(普通类历史),但具体专业组(如205)需结合当年招生计划确认。
建议你直接咨询北京大学招生办(电话:010-62373200)或广东省教育考试院(电话:0755-26018000),获取最精准的2025年报考信息。
祝你备考顺利,金榜题名! 🌟
|
RAG 方案的劣势
第一个也是本质的缺点:RAG 本质上是 “查资料”,不是 “长记性”,模型不会因为你加了文档就变聪明。 RAG 的知识始终外挂于模型参数,模型权重在推理期静态不变,因此不具备真正意义上的知识内化与自演化能力。例如,企业制度分散在 50 个文档里,且每年微调,RAG 能答单文档问题,但答不出 “过去三年里,年假政策演变趋势是什么?”
第二,RAG 的检索质量决定天花板。如果检索质量做到额不好,那么就会出现: 召回不准导致的答非所问、切片策略差导致的上下文断裂、多跳问题引起的遗漏关键片段等问题。而检索问题模型本身无法修复,因为它“看不见”全库。
那么 “知识自增长” 在企业中通常怎么做呢?现实工业界不是二选一,而是分层:
| 层级 |
机制 |
是否“知识内化” |
| 热知识 |
RAG(向量库) |
否 |
| 中频知识 |
Fine-tuning / LoRA |
是:参数级 |
| 核心知识 |
预训练 / 继续预训练 |
是:深度内化 |
| 用户偏好 |
RLHF / DPO |
是:行为内化 |
典型的实现路径:
1 2 3 4
| 新文档 ├─ 实时 → 向量库(RAG) ├─ 周级 → LoRA 微调 └─ 季度 → 全量评估 / 重训
|
标题:
Langchain4j - 基于 Qdrant 实现的 RAG 知识问答系统